改採 App Router 的動態語系段 [locale]
(取代 next.config 的 i18n),整併登入/註冊路由到 /{locale}/...
,並加入字典型別、語系標準化(zh-tw/zh_tw/zh 皆視為 zh-TW)。同時完成登入頁 UI + 驗證(shadcn/ui + RHF + Zod)、Theme Provider 與全域樣式、授權邏輯對語系前綴的支援,以及測試與型別整理。
又看到關於依賴AI導致腦活動力降低的新聞,所以重新試了一下純手寫,只發現已經完全無法接受這樣的開發速度,或許就跟汽車發明一樣,人們跑步的能力變差了,但是會有新的開車能力被開發出來,結論是回不去了==
src/app/[locale]/...
處理多語,移除 next.config i18n;加入 normalizeLocale()
容忍大小寫與連字號差異authorized
可去除語系前綴判斷;NextAuth 預設登入頁改為 /en/login
PageResult<T>
、UsersCursor
於 src/server/types.ts
路由改造(只保留一組語系前綴路徑)
src/app/[locale]/(public)/login/page.tsx
src/app/[locale]/(public)/signup/page.tsx
(暫為占位)src/app/(public)/login/page.tsx
、src/app/(public)/signup/page.tsx
表單與 UI
src/app/[locale]/(public)/login/_components/LoginForm.tsx
:RHF + Zod 驗證、錯誤訊息、連結註冊src/components/ui/button.tsx
、src/components/ui/card.tsx
、src/components/ui/form.tsx
、src/components/ui/input.tsx
、src/components/ui/label.tsx
src/app/(public)/layout.tsx
src/components/theme-provider.tsx
,全域樣式:src/app/globals.css
i18n 與字典
src/lib/i18n.ts
(getMessages
、deriveLocaleFromPathname
、normalizeLocale
)src/lib/i18n/types.ts
(LocaleMessages
)src/lib/i18n/locales/en.ts
、src/lib/i18n/locales/zh-TW.ts
next.config.ts
改為空設定(移除 i18n,避免與 [locale]
路由衝突)Auth 與 Middleware
src/server/auth/index.ts
:authorized
先去除 /{locale}
再判斷公開/保護路由;pages.signIn
改為 /en/login
型別與服務層
src/server/types.ts
:新增 PageResult<T>
、UsersCursor
src/server/users.ts
、src/server/repos/usersRepo.ts
:改用集中型別測試
tests/client/login.test.tsx
:SSR smoke + schema 驗證tests/server/api.users.*.test.ts
:仍通過tests/server/service.users.test.ts
:預設會執行並連線 Postgres(不再自動略過)/en/login
、/zh-TW/login
(亦接受 /zh-tw/login
等變體)normalizeLocale
:將 zh-tw/zh_tw/zh
視為 zh-TW
,en
/en-*
視為 en
LocaleMessages
:確保各語系鍵值一致,避免遺漏src/lib/i18n/types.ts
)export type LocaleMessages = {
title: string;
email: string;
password: string;
submit: string;
signupPrefix: string;
signupLink: string;
errors: {
required: string;
email: string;
passwordMin: string;
invalidCredentials: string;
};
};
src/lib/i18n.ts
)import enMessages from './i18n/locales/en';
import zhTwMessages from './i18n/locales/zh-TW';
import type { LocaleMessages } from './i18n/types';
export const locales = ['en', 'zh-TW'] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = 'en';
const messagesByLocale: Record<Locale, LocaleMessages> = {
en: enMessages,
'zh-TW': zhTwMessages,
};
export function normalizeLocale(input: string | null | undefined): Locale {
const s = (input ?? '').toString().trim().toLowerCase();
if (!s) return defaultLocale;
if (s === 'en' || s.startsWith('en-')) return 'en';
if (s === 'zh' || s === 'zh-tw' || s === 'zh_tw' || s === 'zhtw') return 'zh-TW';
return defaultLocale;
}
export function getMessages(locale: string): LocaleMessages {
const norm = normalizeLocale(locale);
return messagesByLocale[norm] ?? messagesByLocale[defaultLocale];
}
export function deriveLocaleFromPathname(pathname: string): Locale {
const first = pathname.split('/').filter(Boolean)[0];
return normalizeLocale(first);
}
src/app/[locale]/(public)/login/page.tsx
)import LoginForm from '@/app/[locale]/(public)/login/_components/LoginForm';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { getMessages, normalizeLocale, type Locale } from '@/lib/i18n';
export default function LoginPage({ params }: { params: { locale: string } }) {
const locale = normalizeLocale(params.locale) as Locale;
const t = getMessages(locale);
return (
<div className="mx-auto grid min-h-[calc(100dvh-4rem)] w-full max-w-sm place-items-center px-4 py-8">
<Card className="w-full">
<CardHeader>
<CardTitle>{t.title}</CardTitle>
<CardDescription />
</CardHeader>
<CardContent>
<LoginForm locale={locale} />
</CardContent>
</Card>
</div>
);
}
src/app/[locale]/(public)/login/_components/LoginForm.tsx
)"use client";
import { zodResolver } from '@hookform/resolvers/zod';
import { signIn } from 'next-auth/react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import * as React from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { deriveLocaleFromPathname, getMessages, type Locale } from '@/lib/i18n';
export type LoginFields = { email: string; password: string };
export default function LoginForm({ locale: forcedLocale }: { locale?: Locale }) {
const pathname = usePathname();
const locale = forcedLocale ?? deriveLocaleFromPathname(pathname ?? '/');
const t = getMessages(locale);
const router = useRouter();
const [serverError, setServerError] = React.useState<string | null>(null);
const schema = React.useMemo(() => makeLoginSchema(t.errors), [t.errors]);
const form = useForm<LoginFields>({ resolver: zodResolver(schema), defaultValues: { email: '', password: '' } });
const onSubmit = async (values: LoginFields) => {
setServerError(null);
const result = await signIn('credentials', { ...values, redirect: false });
if (!result || result.error) return setServerError(t.errors.invalidCredentials);
router.push('/');
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex w-full flex-col gap-4" noValidate>
{serverError && (
<div role="alert" className="text-destructive text-sm">{serverError}</div>
)}
<FormField name="email" control={form.control} render={({ field }) => (
<FormItem>
<FormLabel>{t.email}</FormLabel>
<FormControl>
<Input {...field} type="email" autoComplete="email" placeholder={t.email} />
</FormControl>
<FormMessage />
</FormItem>
)} />
<FormField name="password" control={form.control} render={({ field }) => (
<FormItem>
<FormLabel>{t.password}</FormLabel>
<FormControl>
<Input {...field} type="password" autoComplete="current-password" placeholder={t.password} />
</FormControl>
<FormMessage />
</FormItem>
)} />
<Button type="submit" disabled={form.formState.isSubmitting}>{t.submit}</Button>
<FormDescription>
{t.signupPrefix} <Link href="/signup" className="underline">{t.signupLink}</Link>
</FormDescription>
</form>
</Form>
);
}
export function makeLoginSchema(errors: { required: string; email: string; passwordMin: string }) {
return z.object({
email: z.string().min(1, errors.required).email(errors.email),
password: z.string().min(1, errors.required).min(8, errors.passwordMin),
});
}
src/server/auth/index.ts
節錄)import { normalizeLocale } from '@/lib/i18n';
export const authConfig = {
pages: { signIn: '/en/login' },
callbacks: {
authorized({ request, auth }) {
const { pathname } = request.nextUrl;
const stripLocale = (p: string) => {
const segments = p.split('/');
const first = segments[1];
const norm = normalizeLocale(first);
if (first && (norm === 'en' || norm === 'zh-TW')) return '/' + segments.slice(2).join('/');
return p;
};
const core = stripLocale(pathname);
if (core.startsWith('/login') || core.startsWith('/signup')) return true;
if (core.startsWith('/api/auth') || pathname.startsWith('/api/auth')) return true;
if (core.startsWith('/_next') || pathname.startsWith('/_next')) return true;
if (core === '/favicon.ico' || pathname.includes('.')) return true;
return !!auth?.user;
},
},
};
[locale]
衝突)(next.config.ts
)import type { NextConfig } from 'next';
const nextConfig: NextConfig = {};
export default nextConfig;